Skip to content

fix(tui,mcp): show MCP servers that failed to start in /mcp, bound to their server and with credentials redacted - #835

Open
Vasanthdev2004 wants to merge 36 commits into
mainfrom
fix/825-mcp-panel-shows-failures
Open

Vasanthdev2004 wants to merge 36 commits into
mainfrom
fix/825-mcp-panel-shows-failures

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #825. Companion to #822, which fixed the same blind spot in zero mcp check.

/mcp worked out each server's state from the config file: disabled if you turned it off, enabled otherwise. But MCP registration is best-effort: a server that can't be reached gets recorded and startup carries on. So a server that never connected showed up as enabled, its tools quietly missing, and nothing in the panel said why.

Startup does know: it prints a warning per skipped server to stderr. That's gone by the time you notice, and /mcp is exactly where you go afterwards to ask what's actually running.

So the skipped set now reaches the panel, and a server that failed renders as failed with the reason under it:

› docs · failed · stdio
  exec: "docs-mcp": executable file not found in $PATH

Two details worth calling out:

The reason comes from the server, so it goes through redaction.ErrorMessage before it's rendered. A handshake error that echoes the Authorization header back would otherwise print the bearer token straight into the transcript. There's a test for that.

Disabled wins over failed. If you turned a server off it was never expected to connect, and calling it failed would be misleading.

The stderr warning is unchanged. Non-interactive users still get it, and the panel is an addition rather than a replacement.

Still not fixed, and out of scope here: enabling a server from inside the TUI updates the config but doesn't reconnect anything, so it'll show as enabled while not actually running until you restart. That's pre-existing and a bigger change; happy to file it separately if you'd like.

Known limit: a credential handed over as a separate argument that starts with a dash (--api-key -Qx...) is read as the next flag rather than a value, so a child that echoes it is not redacted. Without the child's own grammar, a dash-prefixed item is as likely to be an option; the = and packed spellings of the same flag are covered.

Verified with mutation testing: eight mutations across the state builder, the renderer, and both wiring points, all killed. TestAltScreenTranscriptScrollKeepsFooterFixed and TestBuildServeScopeKeepsLexicalPaths fail on my Windows box on clean main too (the second needs symlink privilege).

What else this grew into

Review turned up more than the panel, and those fixes are all here too, so the original title undersold it (thanks @euxaristia). From the most code to the least:

  • The failure reason is treated as credential material. It comes from the server, so everything that could have put a credential into it is redacted before it renders: header and endpoint credentials, configured environment values and credential-named ones a stdio child inherits, URL-valued stdio arguments, and what the token store holds for refresh (tokens, a registered client secret, and the token endpoint, taken apart by the same URL rules as configured endpoints). The store is read through new read-only TokenStore.SecretValues and SecretMaterial. Each value is redacted in its plain, escaped and split forms, and the text has a fixed size bound. A body cut short, by the size limit or by a dropped connection, loses a trailing credential prefix, and a reason whose terminal escapes would eat part of a credential is withheld. internal/redaction/overlapping_secrets_test.go and the MCP tests pin the forms.
  • A failure belongs to one server, and to the credentials it was seen with. It is recorded against the server's identity and a CredentialFingerprint of its credential values. So a renamed or colliding alias doesn't show another server's failure, and neither does a server whose credentials changed since startup.
  • Server names are unique across the whole config before startup splits servers into enabled and skipped, and the write-collision rule matches the disabled-server policy.
  • The manager's rows carry an identity that actions can address, and an MCP manager that is already open is told when background startup finishes.

Summary by CodeRabbit

  • New Features

    • MCP servers that fail to start now appear as failed in the /mcp panel.
    • Failure reasons are shown in the panel and server details when available.
    • Startup failures remain visible while the affected configuration is unchanged.
  • Bug Fixes

    • MCP status now reflects actual startup results, including disabled servers.
    • Failure messages and server targets redact credentials, remove unsafe formatting, and limit excessively long text.
    • Server names and status matching remain consistent across configuration updates.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

MCP startup failures now flow from the CLI runtime into TUI state. The /mcp panel marks skipped servers as failed, displays sanitized reasons, and preserves stderr warnings.

Changes

MCP failure visibility

Layer / File(s) Summary
Runtime failure handoff
internal/cli/app.go, internal/cli/app_mcp_skipped_test.go, internal/tui/options.go, internal/tui/model.go
The CLI passes mcpRuntime.Skipped() through tui.Options. The TUI stores it in model state. Tests verify propagation and stderr reporting.
MCP failure state, retention, and redaction
internal/tui/mcp_state.go, internal/tui/mcp_skipped_invalidation.go, internal/tui/command_views.go, internal/tui/mcp_add_wizard.go, internal/mcp/oauth_store.go
Skipped servers become failed. Disabled servers retain precedence. Skipped state remains only for unchanged configurations. Configured credentials and stored OAuth tokens are redacted.
MCP failure rendering and sanitization
internal/tui/mcp_view.go, internal/tui/mcp_manager.go, internal/tui/mcp_failed_state_test.go
Failure reasons are sanitized, bounded, and UTF-8 safe. The MCP panel and server detail view render the reasons.
Redaction regression coverage
internal/redaction/overlapping_secrets_test.go, internal/mcp/oauth_secret_values_test.go, internal/tui/mcp_failure_redaction_test.go, internal/tui/mcp_redaction_ignorable_test.go, internal/tui/mcp_header_flag_redaction_test.go, internal/tui/mcp_raw_bound_test.go, internal/tui/mcp_target_redaction_test.go, internal/tui/mcp_url_credential_test.go, internal/tui/mcp_candidate_bound_test.go, internal/tui/mcp_oversized_secret_test.go, internal/tui/mcp_path_credential_test.go, internal/tui/mcp_state_entrypoint_test.go
Tests cover overlapping secrets, sensitive arguments, OAuth tokens, invisible Unicode separators, header flags, URL credentials, raw bounds, path credentials, and preserved diagnostics.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPRuntime
  participant CLI
  participant TUIModel
  participant MCPState
  participant MCPView
  MCPRuntime->>CLI: Return skipped server failures
  CLI->>TUIModel: Pass MCPSkipped through tui.Options
  TUIModel->>MCPState: Build failed server state
  MCPState->>MCPView: Provide redacted failure reason
  MCPView->>MCPView: Sanitize and bound rendered output
Loading

Possibly related PRs

  • Gitlawb/zero#188: Both changes modify MCP TUI state propagation and failure rendering across the CLI and TUI.

Suggested reviewers: anandh8x, kevincodex1

Merge Risk: High · up to dc886

The change can display an MCP credential in the /mcp panel and hide the next argument instead, potentially persisting a secret in the transcript. This security issue makes the PR unsafe to merge until packed sensitive arguments are redacted correctly.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed The PR passes Runtime.Skipped() into the TUI and renders failed MCP servers with recorded, sanitized errors as required by issue #825.
Out of Scope Changes check Passed The changes support the linked issue by adding failure-state handling, redaction, sanitization, bounds, canonicalization, and regression tests.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main change: failed MCP servers are shown in /mcp with server-scoped state and credential-redacted errors.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/825-mcp-panel-shows-failures
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/cli/app_mcp_skipped_test.go (1)

63-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the failure reason is forwarded.

The test only verifies the server name. Also assert MCPSkipped[0].Err contains "connection refused" so a regression that drops the recorded error cannot pass.

Proposed test strengthening
 if len(launchedOptions.MCPSkipped) != 1 ||
-	launchedOptions.MCPSkipped[0].Name != "docs" {
+	launchedOptions.MCPSkipped[0].Name != "docs" ||
+	launchedOptions.MCPSkipped[0].Err == nil ||
+	launchedOptions.MCPSkipped[0].Err.Error() != "connection refused" {
 	t.Fatalf("MCPSkipped = %#v, want the failure startup recorded", launchedOptions.MCPSkipped)
 }

As per coding guidelines, add a regression test for behavior changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/app_mcp_skipped_test.go` around lines 63 - 66, Strengthen the
existing MCPSkipped assertion in the test by also verifying that
MCPSkipped[0].Err contains “connection refused,” while preserving the current
server-name check and failure-count validation.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/cli/app_mcp_skipped_test.go`:
- Around line 63-66: Strengthen the existing MCPSkipped assertion in the test by
also verifying that MCPSkipped[0].Err contains “connection refused,” while
preserving the current server-name check and failure-count validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6847c24c-e809-446d-80a2-a6db7325507d

📥 Commits

Reviewing files that changed from the base of the PR and between 097c265 and be58076.

📒 Files selected for processing (8)
  • internal/cli/app.go
  • internal/cli/app_mcp_skipped_test.go
  • internal/tui/command_views.go
  • internal/tui/mcp_failed_state_test.go
  • internal/tui/mcp_state.go
  • internal/tui/mcp_view.go
  • internal/tui/model.go
  • internal/tui/options.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 56c7d55013cd
Changed files (54): internal/cli/app.go, internal/cli/app_mcp_skipped_test.go, internal/cli/mcp_config.go, internal/cli/mcp_identity_operations_test.go, internal/cli/mcp_server_identity_test.go, internal/cli/mcp_startup.go, internal/cli/mcp_write_collision_test.go, internal/mcp/config.go, internal/mcp/credential_fingerprint.go, internal/mcp/network_client.go, internal/mcp/oauth_secret_values_test.go, internal/mcp/oauth_store.go, and 42 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

kevincodex1
kevincodex1 previously approved these changes Jul 30, 2026

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failed-state wiring and secret redaction look good, but the new failure-reason rendering needs terminal sanitization before merge.

BuildMCPViewState passes redaction.ErrorMessage(err, ...) into MCPServerView.Error, and mcpManagerServerLines inserts that value directly into the rendered lines. Redaction removes credentials but does not remove ANSI/OSC sequences, other control characters, or embedded newlines. I reproduced this with an MCP error containing connection refused\x1b[2J\n› forged · enabled; the resulting server line retained both the escape sequence and newline unchanged. A server-controlled handshake error can therefore manipulate the terminal or forge extra /mcp rows.

Please normalize the displayed reason to safe single-line terminal text: strip ANSI/OSC and control characters, flatten CR/LF, apply a reasonable length cap, and add a regression covering escape and newline injection.

Everything else in the change looks correct, and the focused tests and CI are green.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x fixed in c7421d9. You were right, and the reproduction was exact.

Before the fix the panel rendered your payload as:

› evil · failed · http
  connection refused\x1b[2J
› forged · enabled
  actions: zero mcp check evil | ...

Escape sequence intact, forged row on its own line.

sanitizeTerminalReason now consumes escape sequences whole rather than dropping ESC alone, since stripping just the ESC leaves "[2J" printing as visible junk and an abandoned OSC payload can still smuggle a title-set or hyperlink. CSI runs to its final byte, OSC to BEL or ST. Newlines and tabs collapse to spaces so the reason stays on the one row the panel counted for it, other control bytes go, and it caps at 400 runes. Truncation is by rune so a multi-byte character never gets cut in half.

Two regressions, both of which fail on the previous commit: one asserts no escape byte survives, no line carries its own newline, the forged text never starts a row, and the real reason still shows. The other pushes 5000 characters through and asserts the line stays bounded.

Re-requesting you and @kevincodex1, since the push dismissed his approval.

One thing worth flagging beyond this PR: the same class exists at sidebar.go:668, where a failed plan task's raw child error is rendered without sanitizing. I found it reviewing #829 and it is unrelated to this change, but it is the same fix.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tui/mcp_view.go`:
- Around line 192-238: Bound MCPServerView.Error before processing in the
sanitizer around the visible rune conversion and strings.Builder accumulation.
Verify whether Runtime.Skipped() already imposes a strict size limit; if not,
limit the raw input before converting to []rune and stop accumulating once the
maxMCPReasonLen display budget is reached, while preserving ANSI stripping,
whitespace normalization, and truncation behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 63dc33b5-a906-45b6-b233-5fc22f09514c

📥 Commits

Reviewing files that changed from the base of the PR and between be58076 and c7421d9.

📒 Files selected for processing (2)
  • internal/tui/mcp_failed_state_test.go
  • internal/tui/mcp_view.go

Comment thread internal/tui/mcp_view.go
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Pushed one more commit for the bot's finding, which was real. The 400 rune cap ran at the end, so the sanitizer walked the whole server string first, and escape sequences get consumed without producing output. 64KB of \x1b[2J was walked in full and the text after it still rendered. Nothing upstream bounds the handshake error and the panel re-runs this every redraw, so the input is now capped at 16KB before the walk, with a trim so a split character never shows up as a replacement glyph.

The bot's other claim on #866 (duplicate alwaysPromptingTool declaration) is wrong, there is only one.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 5, 2026
gnanam1990
gnanam1990 previously approved these changes Aug 5, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve

Verified empirically on the branch (checked out, built).

What I checked

  • Gut-the-fix: forcing the failure branch off (mcp_state.go:71) so a skipped server renders as "enabled" turns the TUI MCP tests red. The tests exercise the behavior.
  • Precedence is right and tested: a server that is both disabled and recorded-failed shows as "disabled", not "failed" (mcp_state.go:66-69), and this is asserted directly (mcp_failed_state_test.go:45 — "disabled to win over a recorded failure"). Correct — a server you turned off was never expected to connect.
  • Reason is redacted before display (redaction.ErrorMessage, mcp_state.go:73) — invariant 6, so a path/credential in a startup error can't leak into the panel. Empty-error fallback ("server did not start") is handled too.
  • Skipped set reaches the panel via MCPSkipped: mcpRuntime.Skipped(); companion to #822 which did the same for zero mcp check.
  • Clean scope — every file is the MCP panel or its plumbing.

Worth a quick confirm (non-blocking)

  • On reconnect (zero mcp enable after a failure), the panel is rebuilt from a fresh Skipped set, so it should flip back to enabled — worth a sanity check that the runtime clears the entry on a successful re-register.

Good fix.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Merge and review state

  • Review decision: GitHub still reports CHANGES_REQUESTED (anandh8x’s review on be580766 is not dismissed). Terminal sanitization and raw-input bounding from that review look addressed on the current head, but the merge gate still needs a fresh approval on 6521c367. gnanam1990 approved on 6521c367; CodeRabbit approved on the same head.
  • Mergeability: MERGEABLE, no conflict markers in the PR diff. mergeStateStatus is BLOCKED pending required reviews.
  • Checks: All CI / CodeQL / Zero Review / CodeRabbit checks passed on head.

Prior review alignment

gnanam1990’s approval is valid for what they checked: skipped servers map to failed, disabled wins over recorded failure, reasons are redacted at build (redaction.ErrorMessage), MCPSkipped reaches the TUI, and the focused tests go red when the failure branch is gutted. Those checks exercise buildMCPServerViews, renderMCPView, and m.mcpText() — not the bare /mcp manager overlay entry point. Their non-blocking note about enable clearing Skipped on reconnect is separate from this finding; live reconnect from the TUI is out of scope per the author, and mcpSkipped remains a startup snapshot.

That approval does not negate the remaining gap below: state and redaction work on the transcript/renderMCPView path, but the overlay users get from bare /mcp still omits the reason line.

Findings

  • [P2] Bare /mcp shows failed in the manager overlay but not the failure reason
    internal/tui/model.go (commandMCP → openMCPManager), internal/tui/mcp_manager.go (mcpManagerOverlay, mcpManagerServerMeta, mcpManagerSelectionDetail), internal/tui/mcp_view.go (mcpManagerServerLines, renderMCPView)
    Empty /mcp has long routed to openMCPManager() — this PR did not change that. What it did change is meaningful: buildMCPServerViews now marks skipped servers as failed, so the overlay meta and detail pane correctly say failed instead of the pre-PR enabled with missing tools. The recorded reason, however, is rendered only in mcpManagerServerLines inside renderMCPView(). The overlay never reads server.Error or calls sanitizeTerminalReason, so a user who types /mcp after a startup warning sees the right state but not the “why” issue #825 and this PR’s description target. The renderMCPView path does show the sanitized reason — on /mcp list and other transcript subcommands, and in transcript output appended after manager actions such as check or list — but the primary overlay surface is still incomplete relative to the stated fix. TestModelMCPPanelReportsStartupFailures exercises m.mcpText() / renderMCPView(), not the bare /mcp entry point. Please surface the sanitized reason in the manager overlay (list meta, selection detail, or both), reusing the same sanitizeTerminalReason path mcpManagerServerLines already uses.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 9, 2026
Vasanthdev2004 added a commit that referenced this pull request Aug 9, 2026
An empty /mcp opens the manager overlay, and it reported the state without the
reason. The recorded "why" was rendered only by mcpManagerServerLines inside
renderMCPView, which serves /mcp list and the transcript, so the panel said
"failed" and stopped exactly where someone goes to find out why after the
startup warning has scrolled away.

The reason now sits in the selection detail, directly under the header and above
the target, through the same sanitizeTerminalReason path the transcript uses.

TestModelMCPPanelReportsStartupFailures drives m.mcpText() and passes with or
without this, which is how the gap survived review. The new tests drive
openMCPManager().mcpManagerOverlay() instead. Mutation-verified: feeding the
sanitizer an empty reason fails the first one.

The sanitization test deliberately does not search for a bare escape byte. The
overlay is lipgloss-styled and therefore full of escape sequences it wrote
itself, so the assertion is that the SERVER's payload did not survive: no
clear-screen sequence, and no row carrying the forged text on its own. The
sanitizer collapses the newline, so the forged text stays inert on the reason
line rather than becoming an entry of its own.

Reported by jatmn on #835.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and fixed the overlay gap. Thanks @jatmn, that was exactly right and the reason it survived review is worth stating.

The finding. Bare /mcp opens the manager overlay, and the reason was rendered only by mcpManagerServerLines inside renderMCPView, which serves /mcp list and the transcript. So the overlay said failed and stopped, on the one surface a user reaches after the startup warning has scrolled away. The sanitized reason now sits in the selection detail, directly under the header and above the target, through the same sanitizeTerminalReason path.

Why it got through. TestModelMCPPanelReportsStartupFailures drives m.mcpText(), and it passes with or without the fix. The new tests drive openMCPManager().mcpManagerOverlay(). Mutation-verified: feeding the sanitizer an empty reason fails TestBareMCPOverlayShowsTheFailureReason.

One thing I got wrong while writing the sanitization test, worth recording. My first version asserted the overlay contained no \x1b. It failed immediately, and not because of the payload: the overlay is lipgloss-styled, so it is full of escape sequences this code wrote itself. The real assertion is that the SERVER's bytes did not survive, so it now checks for the clear-screen sequence specifically, and that no row carries the forged text on its own. The sanitizer collapses the newline, so › forged · enabled stays inert on the reason line instead of becoming an entry of its own, which is the property that actually matters.

The rebase. Two conflicts against #884: Options gained PeerService beside this PR's MCPSkipped, and in app.go main had moved SessionStore to a variable. Both unions.

go build, go vet, gofmt -l clean. The internal/tui failure is TestAltScreenTranscriptScrollKeepsFooterFixed, which reproduces on a clean tree here, and the internal/cli ones are the RunDoctor and BuildServeScope tests, same story. All pre-existing and local to Windows.

@anandh8x your changes-requested predates the terminal-sanitization work you asked for, which landed a while back; a re-look when you have a moment would unblock this.

@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn August 9, 2026 14:07
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 9, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Redact the configured server credentials before retaining its error
    internal/tui/mcp_state.go:73
    ErrorMessage is called with empty options, even though remote MCP clients send every value in MCPServerConfig.Headers. A server can echo an arbitrary configured value (for example X-Workspace-Credential: <value>) in its failed startup response; that value is not covered by the generic redaction patterns, is saved in MCPServerView.Error, and is newly rendered in both /mcp surfaces and the session transcript. Pass the configured secret values into redaction (and cover an echoed custom header); this also avoids relying on the syntactic Authorization: matcher, which terminal control bytes can evade before the later sanitizer removes them.

A server that normalizes base64 before echoing drops the padding, so the
message carries the body while exact-value redaction only knows the padded
spelling. The whole value already had its unpadded form; the forms a known
value is taken apart into did not, and "Bearer <base64>==" is the usual shape.

Known forms get it at any length, ambiguous ones only when what is left still
passes the readability floor, and stored tokens are treated like the rest.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn all seven are fixed on 6cc1ec40. You were right about the pattern as well: each earlier fix covered one dimension and a later stage lost the fact it depended on, so this pass carries the facts through instead of adding examples.

1. Padding taken for the boundary. There is now one parser (splitMCPArgValue, in mcp_arg_parse.go) that finds where the flag ends before anything inside the value is read: whichever of the first = and the first whitespace comes earlier, with the header spellings recognised by prefix before that. The failure collector, the Target row and the URL collector all read that one result. Both of your examples keep the whole credential, Target prints --api-key [REDACTED], and --verbose and the header name stay readable. The test echoes the full value and the bare body.

Writing that regression turned up two more problems in the same area, both mine:

  • A positional endpoint (mcp-remote https://u:pw@host/mcp?token=x) was cut at its query =, so the "flag" was https://u:pw@host/mcp?token, which names a credential. The previous head printed the userinfo next to a masked query value. My first version of this fix made it worse: the element fell through to the bare-flag reader, was printed whole, and the next argument was masked in its place. The regression caught that before I pushed. An element that is a URL is now read as one before any = reader sees it.
  • Exact-value redaction knew YWJjZGVmZ2hpag==, and an echo of the body without its padding matched nothing. Every known form now also offers its unpadded spelling, bearer tails included. Ambiguous values only get it when what is left still passes the eight-byte floor.

2. Short bearer. knownCredentialTails keeps the tail of the supported shapes, <scheme> <credential> and <header>: [<scheme>] <credential>, without the floor: at most two name-like words, then one token. A multi-word known value is not taken apart, and the separator scan stays inside the existing 256-byte prefix, so the oversized-value and bounded-expansion tests still hold. mode=sse stays readable next to a redacted s3cr3t, for the configured header and for the three stdio header spellings.

3. The cut made by the HTTP reader. httpStatusError now returns an error that says whether it cut the body (mcp.ErrorDetailTruncated), and the display repairs the tail when either side made the cut. It reads one byte past the limit to tell "exactly 1024" from "there was more". The regression is your body, 250 erase-line sequences and then the 32-byte value, served as a real 500 and registered through mcp.RegisterTools with the real client. Controls: an ordinary error shown whole, and a sliced body that is not a credential keeping its tail. A second test in internal/mcp pins the other half of the contract for http and sse: the marker survives the wrapping, and the retained body stays at the end of the error, because the tail is the only place the repair looks.

4 and 5. Fragment and escaped password. Fragment pairs are collected with the classification Target already uses (known under a credential key, otherwise the floor), in raw and decoded form. The raw userinfo password is known, like the decoded one, and the username stays ambiguous. Both regressions start a real stdio child that reports what it was handed (the whole endpoint, the raw password alone, the decoded password alone, the username alone) and check the Error and Target rows separately. A bare #anchor with no = is left alone, which is what Target does with it.

6. Enable. setServerDisabled validates the prospective enabled entry with refuseColliding before anything is written. The test runs zero mcp enable ' docs' against a temp file and asserts failure, identical bytes and a clean reload. Then a non-conflicting enable, and disable and remove on a file that already collides.

7. Check. runMCPCheck keeps the exact key for lookup and for output, and matches the registration outcome by the name NormalizeConfig derives from it. The test drives check " docs " with a real failed registration (the binary does not exist): unreachable and a non-zero exit, text and JSON, with docs as the control, and a healthy padded key still reports ok. A tui test pins that Enter and Alt+c dispatch the same ["check", " docs "], so the one receiving path covers both.

I reverted each fix on its own and the named test fails with the output you described: ["="] from the collector, s3cr3t on both surfaces, Qw7ZmPr4aBcD9eFgH2jKlM6n, the fragment on the Error row, %73crt, enable reporting success, check returning ok.

One existing test changed. TestMCPManagerTargetsRedactSecretsFromURLsAndArgs renders at 320 columns instead of 260: the --endpoint URL in that fixture used to lose its fragment (it was masked by accident as part of the query value) and now keeps #token=[REDACTED], which pushed the env assertion past 260.

Not covered: the enable check validates the file being written, the same as add, so a collision across config layers is still only caught at load.

jatmn
jatmn previously approved these changes Sep 19, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

gnanam1990
gnanam1990 previously approved these changes Sep 21, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Approve — and this withdraws my CHANGES_REQUESTED from 072e0366, which is what has been holding this branch alongside jatmn's approval.

Reviewed at 6cc1ec40, base 99721c76.

Withdrawing my previous block

My 2026-09-12 review said explicitly "I found no new code-level blocker" and requested changes only because the branch was 29 commits behind main. That is done: 6cc1ec40 is 0 behind origin/main, so the reason no longer exists. Changes-requested survives a push where an approval does not, so it needed a new submission rather than an edit — hence this review.

What I verified on this head

  • Merged tree: git merge origin/main is already-up-to-date; go build ./..., go vet ./... and the full go test ./... are clean on macOS.
  • Gut-the-fix on four load-bearing behaviours — each reverted in turn, internal/tui rerun, all four caught:
    1. a recorded failure rendering as failed — revert and the panel shows a server that never started as enabled
    2. the trimmed-identity lookup (strings.TrimSpace(rawName)) — revert to the raw map key and " docs " misses its failure entry, which is the exact defect the comment at mcp_state.go:85-97 describes
    3. disabled winning over a recorded failure
    4. redactMCPFailureReason on the displayed reason — replacing it with a plain err.Error() is caught, so invariant #6 is held by a test and not just by the call site

That last one is the one I most wanted to see fail, since a startup error is exactly where a path or bearer leaks into a panel.

Follow-up from my earlier pass, now closed

I had asked (non-blocking) whether a successful re-register clears the entry so the panel flips back to enabled. The observation now carries its credential fingerprint (SkippedCredentials, mcpCredentialFingerprint) and buildMCPServerViews compares against it, so a stale failure is not rendered against refreshed material. That answers it.

Not covered

macOS only. I did not exercise a real failing MCP server end-to-end, nor the Windows paths; the evidence above is the merged tree plus targeted mutation of the display logic.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@coderabbitai the three open threads, checked against 6cc1ec40:

  • Token across the cut (mcp_raw_bound_test.go). That test puts the credential across the 16 KiB cap, inside the overlap window, which is the case the window is there for. The real cut at cap plus window, and the trailing-prefix repair that handles it, are covered by TestNoCredentialPrefixSurvivesTheRawBound in mcp_credential_boundary_test.go: a credential that starts before the tail window, and an eight-byte one split after seven.
  • Short configured values (mcp_error_redaction_test.go). Valid. The test passes just as well with the 1 and the true redacted: with the floor lowered to one, the message comes back as "connection refused after [REDACTED] attempt in [REDACTED] isolation" and the substring check still holds. Comparing the whole message catches that.
  • C1 fixture (mcp_failure_redaction_test.go). Valid. The literal U+0090 becomes the \u0090 escape, same input.

Both of those are test-only and I have them ready. Pushing now would dismiss both approvals, so I'm holding them. They go out with the next change this PR needs, or right after merge if there isn't one.

@coderabbitai

coderabbitai Bot commented Sep 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
git rev-parse --short HEAD
fd 'mcp_(raw_bound|credential_boundary|error_redaction|failure_redaction)_test\.go$|mcp_state\.go$|mcp_view\.go$' .
ast-grep outline internal/tui/mcp_raw_bound_test.go --match 'TestASecretStraddlingTheBound|Test' --view expanded
ast-grep outline internal/tui/mcp_credential_boundary_test.go --match 'TestNoCredentialPrefixSurvivesTheRawBound' --view expanded
rg -n -C 5 'TestASecretStraddlingTheBound|TestNoCredentialPrefixSurvivesTheRawBound|TestShortConfiguredValuesDoNotShredTheMessage|0090|longestPrefixSuffix|maxMCPReasonRawLen|maxMCPSecretMatchWindow' internal/tui/mcp_raw_bound_test.go internal/tui/mcp_credential_boundary_test.go internal/tui/mcp_error_redaction_test.go internal/tui/mcp_failure_redaction_test.go internal/tui/mcp_state.go internal/tui/mcp_view.go

Length of output: 13997


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '43,84p' internal/tui/mcp_raw_bound_test.go
sed -n '1,108p' internal/tui/mcp_credential_boundary_test.go
sed -n '65,93p' internal/tui/mcp_error_redaction_test.go
rg -n -C 7 'C1|U\+0090|\\u0090|\\x90|TestFailedServerReasonRedactsASecretSplitByControlBytes' internal/tui/mcp_failure_redaction_test.go
sed -n '195,233p' internal/tui/mcp_state.go
sed -n '265,291p' internal/tui/mcp_state.go
rg -n -C 3 'dropTrailingSecretPrefix|boundMCPFailureError|stripTerminalRejoiners' internal/tui/mcp_state.go

Length of output: 14035


@Vasanthdev2004, the additional inspection confirms the raw-bound distinction. TestASecretStraddlingTheBoundIsStillFullyRedacted crosses the 16 KiB cap. TestNoCredentialPrefixSurvivesTheRawBound crosses the actual cut at the cap plus the 4 KiB overlap and checks that no credential prefix survives. I withdraw the raw-bound test finding.

The other two test findings remain open. TestShortConfiguredValuesDoNotShredTheMessage checks only for "connection refused", so it would miss redaction of 1 and true. The C1 fixture still contains a literal U+0090. Your proposed test-only changes address those findings, but they have not landed.

Thanks for the precise boundary explanation.


✏️ Learnings added
Learnt from: Vasanthdev2004
URL: https://github.com/Gitlawb/zero/pull/835

Timestamp: 2026-09-24T10:00:02.135Z
Learning: In `internal/tui/mcp_raw_bound_test.go`, `TestASecretStraddlingTheBoundIsStillFullyRedacted` covers a credential crossing the 16 KiB display cap inside the overlap window. In `internal/tui/mcp_credential_boundary_test.go`, `TestNoCredentialPrefixSurvivesTheRawBound` covers a credential crossing the actual raw-error cut at `maxMCPReasonRawLen + maxMCPSecretMatchWindow`, including trailing-prefix repair. Do not treat the first test as the only coverage of the actual cut.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@euxaristia euxaristia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failure reasons come from the server and pass the redaction helper before rendering (with dedicated tests for authorization-header echo, oversized secrets, and path credentials) plus truncation bounds, and disabled wins over failed in the display. Scope note: this bundles credential fingerprints, an OAuth store, and identity work well beyond a fix(tui) title, though it arrives with about 34 test files.

@Vasanthdev2004 Vasanthdev2004 changed the title fix(tui): show MCP servers that failed to start in /mcp fix(tui,mcp): show MCP servers that failed to start in /mcp, bound to their server and with credentials redacted Sep 28, 2026
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x your 3 August change request is still the block here. The terminal-sanitization finding was fixed in c7421d9 two days later, and your payload is pinned by TestMCPFailureReasonCannotInjectTerminalControl on the current head 6cc1ec4. jatmn and gnanam approved that head. Could you take another look when you have a moment? @kevincodex1, for the owner's view: the request is against be58076.

…ws-failures

# Conflicts:
#	internal/tui/command_views.go
#	internal/tui/mcp_state.go
main moved the module to github.com/Twigpine/zero in #1105. Rename this
branch's imports to match, including the two in internal/workspaceindex
that main itself still carries until #1153 lands.
@Vasanthdev2004
Vasanthdev2004 dismissed stale reviews from gnanam1990 and jatmn via 1185361 October 17, 2026 07:37
@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn October 17, 2026 07:38
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn @gnanam1990 this push dismissed your approvals, sorry. It merges main at 2ffe183 and renames this branch's imports to the Twigpine module path from #1105, which the branch needed before it could build on main. The only conflicts were the import blocks in internal/tui/command_views.go and internal/tui/mcp_state.go, where this branch adds the internalmcp and redaction imports; both keep this branch's set on the new path. Nothing else changed from 6cc1ec4. The rename also covers the two internal/workspaceindex imports that #1153 fixes on main, so that hunk drops out once #1153 lands. go vet for windows, linux and darwin, go test ./..., the release build and smoke, and govulncheck all pass here. Could you take another look when you have a minute?

…the C1 fixture

TestShortConfiguredValuesDoNotShredTheMessage checked only that
"connection refused" survived, so it passed just as well with the 1 and
the true redacted, which are the values it exists to protect. It now
compares the whole message.

The C1 control fixture held a raw U+0090, which staticcheck reports as
ST1018. It is written as an escape now; the input is the same.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

One more push on top of the rename: 761fbd3 carries the two CodeRabbit test fixes I had held since September so as not to dismiss approvals. The short-value test now compares the whole message, and the C1 splitter is written as an escape sequence instead of a raw invisible character. Test-only; go test ./..., vet for three platforms, the release build and smoke, and govulncheck pass here.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked this PR on head 27218dc (full diff, focused tests, blind search). One code item remains before I’d approve; the rest of what I raised on 42a95b9 (redaction pipeline, arg boundaries, manager ConfigKey, optional completion, collision/enable paths, etc.) is addressed on current head or is explicitly out of scope for #825.

Merge readiness

  • Branch is not behind origin/main; required CI on 27218dc is green.
  • My CHANGES_REQUESTED on 42a95b9 is stale relative to this head—treat it as superseded by this note once the finding below is fixed (or tell me if you want any prior thread item re-opened with a specific repro on 27218dc).

🟡 P2 — Use each row’s ConfigKey in transcript MCP action hints

📍 Where: internal/tui/mcp_view.go (mcpServerActionLine, consumed from mcpManagerServerLines in the /mcp transcript text).

💥 What fails: The panel lists suggested CLI commands with the canonical runtime name, not the configuration map key:

actions := []string{"zero mcp check " + name} // name = strings.TrimSpace(server.Name)

For a server stored under a padded key such as " docs ", the row displays as docs but zero mcp check (and siblings) look up cfg.Servers[serverName] by exact key. The manager overlay already dispatches check/disable/enable/remove with ConfigKey; copying the transcript hint runs the wrong command or reports “not configured” while the panel still shows a failed server.

🔎 Root cause: This PR split display identity (Name, trimmed runtime) from action identity (ConfigKey, exact map key) and wired ConfigKey through the manager, but the transcript action line still builds commands from Name only—the last in-diff operator surface missing from the same identity contract you already applied in mcp_manager.go.

📜 Stated contract:

ConfigKey is the EXACT key in the configuration map, untrimmed. Actions address the config by that key, so it is the only identity that can select one row when a padded alias renders under the same canonical name.

🏷️ Attribution: Incomplete fix on this PR at 27218dc. Misleading hints for padded keys existed at merge-base too; what’s new is the documented ConfigKey rule and manager compliance—transcript hints need to match in the same pass.

📌 In this PR:

  • internal/tui/mcp_view.go — mcpServerActionLine / mcpManagerServerLines transcript actions (must use ConfigKey)
  • internal/tui/mcp_manager.go — already correct (do not regress)

🔒 Unchanged on main: CLI key lookup; stderr / mcp check redaction (still generic redaction.ErrorMessage as before this PR’s panel work).

🔧 Required correction: Build every zero mcp … hint in mcpServerActionLine from server.ConfigKey (shell-quote when the key contains whitespace or shell metacharacters). Add a regression test with a padded key that the action line includes the exact map key, not only the trimmed display name.

🛠️ Author fix: Fix transcript action hints and the test in one pass; leave manager dispatch, CLI, registration, stderr, and mcp check redaction alone unless you’re deliberately expanding scope.

🚫 Out of scope for this round: Panel-grade redaction on stderr / mcp check / ACP; optional MCP “enabled” while background registration runs; view cache keyed on late-failure count only; in-session re-register after config edits (per your PR note).

The commands printed under each server in the /mcp transcript were built
from the trimmed display name, while check, enable, disable and remove
look the server up by its exact config key. For a padded key such as
" docs " the hint reported "not configured", and for an alias pair it
addressed the other entry. The manager overlay already dispatches with
ConfigKey; the transcript hints now use it too.

A key with space padding is double-quoted, which keeps the spaces in
POSIX shells, PowerShell, cmd and /mcp. Padding that cannot be typed back
from a terminal line, such as a tab, gets no key-addressed hint. oauth
login keeps the trimmed name, because it matches the normalized server
list rather than the config map.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn fixed in 3a9dcb0, plus a merge of main.

The check, enable, disable and remove hints in the transcript now use ConfigKey. A key with space padding is double-quoted, so " docs " shows as zero mcp check " docs ". Double quotes keep the spaces the same way in bash, PowerShell, cmd and /mcp, and nothing else in a valid name is special inside them. I ran them against a throwaway config with a " docs " key: the quoted form reaches the entry from bash, PowerShell and cmd, while zero mcp check docs and zero mcp disable docs both report it as not configured.

Two cases don't use the key:

  • oauth login keeps the trimmed name. resolveOAuthServer matches server.Name from NormalizeConfig, so zero mcp oauth login " linear" reports "not configured". Switching it would have broken the one hint that already works.
  • A key padded with a tab, a newline or a Unicode space gets no check/enable/disable/remove hint. Printed raw, those either look like plain spaces, so a copied hint would address a different key, or they put a control byte in the transcript. The overlay still acts on that row by ConfigKey.

TestTranscriptActionHintsAddressTheExactConfigKey covers four cases: a padded single key; an alias pair, where the disabled " docs" row's old hint pointed at the enabled docs; oauth login; and the tab case. All four subtests fail on 27218dc and pass on this head. The full suite, build, smoke, vet for windows/linux/darwin and govulncheck for linux and windows are clean.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found four issues that need to be addressed before this is ready. The latest transcript action-hint fix is correct, including the author's distinction between exact configuration keys and OAuth's normalized names. I also verified the explanations about disabled aliases, the overlap-versus-cut tests, and the deliberately excluded reconnect/stderr behavior.

Reviewed head 2c36c67ac8be29ff13b59b18f580ac656b9cb6e0 against merge-base and live main 4247a07aba0f89c3c2597314a33edaede56be70c.

Merge readiness

The branch is mergeable and 0 commits behind main; no rebase or release-metadata conflict was found. All 12 executed checks succeeded; CodeRabbit was skipped. GitHub reports BLOCKED / CHANGES_REQUESTED, so fresh review approval is needed after the fixes. The closed #822 diagnostic issue is complementary, and merged #1113 supplies the OAuth fields involved below; neither supersedes #835.

Findings

🟠 P1 — Include supported runtime credentials in the new failure-redaction input

📍 Where: internal/mcp/oauth_store.go:286-294 (SecretValues) and internal/tui/mcp_state.go:915-921 (environment collection).

💥 What fails: Three real startup paths disclose an opaque credential in both the transcript panel text and manager detail:

  • With auth: oauth and no configured OAuth block, a stored dynamically registered ClientSecret is submitted on refresh. A token endpoint's error_description echo is displayed verbatim.
  • A credential in the discovered/stored TokenEndpoint is likewise absent from the new redaction input. A real failing token POST that echoes its workspace query value exposes it.
  • A stdio child can inherit COMPANY_TOKEN from the launching environment, as the documented secret-manager setup permits. When the child reports that value while failing initialization, the new reason displays it even though Target contains no credential.

I reproduced the last case with a real child through the existing execution adapter's supported degraded path; its environment scrubber leaves this unlisted variable available. All three corresponding display probes pass at the merge base, where startup error text is not rendered.

🔎 Root cause: The new safety input is reconstructed from configured fields and only part of stored OAuth material:

values = append(values, stored.AccessToken, stored.RefreshToken)
// The configured-environment collector separately visits only raw.Env.

It misses accessible credentials that the supported startup paths actually use. The token-context fingerprints also receive the incomplete stored set.

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

“ClientSecret is sensitive.” — the existing StoredToken contract

“A secret manager that injects the env var your MCP server reads on its own (the command and args then run inside that environment).” — docs/EXTENDING.md

🏷️ Attribution: PR-introduced disclosure. Stored discovery fields and inherited environment already exist on base/main, but the newly added failure-reason path exposes them. This is not a request to repair the older stderr policy.

📌 In this PR:

Surface Current result
oauth_store.go:SecretValues and its new tests Access/refresh tokens covered; stored client secret and token-endpoint credential components missing
mcp_state.go:mcpServerSecretValues Configured headers/env/args/URL/OAuth covered; inherited sensitive stdio variables missing
registry.go, cli/app.go, tui/model.go, command_views.go Capture/current-context handling consumes the same incomplete credential inputs
mcp_view.go and mcp_manager.go Both consume the resulting unsafe reason; shared input correction should protect both

🔒 Unchanged on main: Login, refresh, environment merging/scrubbing, and token persistence already implement these startup sources. Public client IDs, scopes, expiry, token types and ordinary environment metadata are not credentials.

🔧 Required correction: Extend the PR's new safety-input collection to cover the stored client secret, credential-bearing stored token-endpoint components using the existing URL rules, and inherited credential-named stdio variables such as COMPANY_TOKEN. Keep capture/current context consistent for mutable stored material. Preserve read-only enumeration, existing bounds, ordinary metadata and current configured-value protection. Verify real refresh/child failures through both displays, including relevant credential-change suppression.

🛠️ Author fix: Close the missing-input rule across every row above in one pass, including source comments and regression fixtures. Do not fix only the OAuth example or only the environment example; do not require a new observation architecture or plaintext credential store.

🚫 Out of scope: OAuth protocol changes, sandbox environment policy, introspecting secrets created inside arbitrary wrapper scripts, unrelated logging and reconnect behavior.


🟠 P1 — Preserve query credential provenance across raw and decoded spellings

📍 Where: internal/tui/mcp_state.go:1103-1134 (mcpURLSecretValues).

💥 What fails: Real HTTP registration failures expose configured URL credentials in both displays:

  • ?api%5Fkey=%73crt followed by a value-only echo of %73crt: Target recognizes the decoded sensitive key and masks it, but Error prints the recoverable raw credential spelling.
  • ?workspace=opaque%2Dworkspace%2D9f3c2b7&filter=a;b followed by a decoded-value echo: the unrelated semicolon error causes the valid decoded workspace candidate to be discarded. An unrelated invalid escape has the same effect. A normal valid-query control is protected.

🔎 Root cause: The raw pass classifies an undecoded key, while the decoded pass is discarded wholesale on any query parse error. Raw and decoded representations therefore lose either their credential classification or their membership in the candidate set.

if isSensitiveMCPDisplayKey(rawKey) { /* ... */ }
query, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
    return known, values
}

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

The existing TestShortValuesUnderSensitiveKeysAreRedacted and TestPercentEncodedEndpointCredentialsAreRedacted assert the specific supported contracts: known credentials bypass the readability floor, and configured escaped/decoded URL values remain protected.

🏷️ Attribution: PR-introduced. The collector and rendered failure reason are new; identical real transport/display probes do not expose these values at base/live main.

📌 In this PR:

Surface Current result
Main server URL Both failures reproduced
Positional/separate/packed URL-valued stdio arguments Same mcpURLSecretValues collector
Configured OAuth token, authorization, registration and issuer URLs Same collector
Target query renderer, fragment collector and raw userinfo-password handling Already retain the relevant decoded-key/per-value provenance; preserve these
New URL/provenance/assembled-state tests Cover valid escaped values, but not escaped short-value keys or a valid pair beside a parse error

🔒 Unchanged on main: URL admission and generic redaction. The accepted URL loader does not reject these query strings; this finding does not require changing that policy.

🔧 Required correction: Classify each raw query value using its decoded key, retaining both supported value spellings with that classification. Preserve independently decodable pairs when another pair fails. Use the corrected shared helper for every listed caller, with regression controls for ordinary short metadata and all demonstrated combinations.

🛠️ Author fix: Fix the query extraction rule and all listed caller coverage together. Do not patch one parameter name or one URL source; do not rewrite core URL validation or generic redaction.

🚫 Out of scope: New encoding schemes, broad configuration rejection rules and unrelated CLI output.


🟠 P1 — Mark interrupted HTTP error details as incomplete

📍 Where: internal/mcp/network_client.go:643-646 (httpStatusError).

💥 What fails: An HTTP or SSE server returns 500 with Content-Length: 2048, sends only the first 24 bytes of a configured 32-byte opaque credential, then closes. The real client retains those bytes, but ErrorDetailTruncated is false. Both new failure displays show Qw7ZmPr4aBcD9eFgH2jKlM6n, 24 of the 32 secret bytes. Complete-body controls remain protected.

🔎 Root cause: The new incompleteness signal recognizes only the size cutoff and discards the body-read failure:

body, _ := io.ReadAll(io.LimitReader(response.Body, maxHTTPErrorDetail+1))
truncated := len(body) > maxHTTPErrorDetail

A short partial body cannot match the complete configured credential, and neither this false marker nor the local 20 KiB bound activates the existing tail repair.

📜 Stated contract:

“Redact success and error paths (including stderr).” — AGENTS.md

The existing TestCredentialPrefixCutByTheHTTPReaderIsNotDisplayed protects credential prefixes cut at the producer; this is an observable interrupted-read variant of that same boundary.

🏷️ Attribution: PR-activated disclosure. Base/main already discard the read error, but do not render this startup body in the MCP views. The PR adds both the reason consumers and the incomplete producer marker. Identical HTTP/SSE display probes pass at base.

📌 In this PR:

  • httpStatusError: shared by HTTP POST, SSE stream GET and SSE POST.
  • ErrorDetailTruncated / redactMCPFailureReason: existing new consumer correctly repairs marked partial text.
  • Both reason renderers: affected through shared state.
  • New producer/truncation tests: size-cut and complete-body cases covered; read-error case absent.

🔒 Unchanged on main: Network transport architecture and old stderr/check behavior.

🔧 Required correction: Treat a failed body read as incomplete, or withhold its partial detail while retaining useful status context. Preserve byte bounds and complete-response behavior. If reusing the flag, keep size slicing dependent on actual body length so a short failed read cannot panic. Add interrupted-response regressions through real HTTP/SSE registration and both consumers.

🛠️ Author fix: Close the shared producer incompleteness rule for every listed route in one pass. Reuse the existing marker/tail handling; do not replace the networking framework or expand this into arbitrary partial-string recognition.

🚫 Out of scope: Detecting prefixes deliberately authored as complete error messages or changing unrelated error-output policy.


🟡 P2 — Isolate the new startup-wiring test from user token stores

📍 Where: internal/cli/app_mcp_skipped_test.go:31-68.

💥 What fails: The test leaves newMCPTokenStore at its production default. Running this exact new test with simulated user ZERO_MCP_OAUTH_TOKENS_PATH / ZERO_OAUTH_TOKENS_PATH values migrated the seeded legacy token, renamed its original file and created the unified store. On Windows, the helper sets only APPDATA, while both OAuth paths fall back through HOME/USERPROFILE; an inherited keyring backend also bypasses a temporary config path.

The reproduction used only review-owned simulated user files.

🔎 Root cause: Redirecting the config root does not isolate the credential-store constructor or its migration/backend selection.

📜 Stated contract:

“Tests must not read or write the developer's real config, cache, or state directories.”

“Redirect every root the code under test resolves, not just config.” — AGENTS.md

🏷️ Attribution: PR-introduced test defect. This test file and constructor invocation path are absent at base/live main. The old shared helper's limitations do not justify adding another uncontained caller.

📌 In this PR:

  • New startup test: production token constructor and inherited backend/path settings remain active.
  • New direct token-store helpers in internal/mcp and internal/tui: already use explicit temporary paths and Env: map[string]string{}.
  • New CLI identity/check tests: non-OAuth stdio paths do not invoke this token constructor.

🔒 Unchanged on main: Credential-store and legacy-migration implementations; unrelated older tests.

🔧 Required correction: Supply an explicitly temporary file-backed token store with an isolated environment in this new test, or equivalent complete local isolation that also prevents legacy migration and host-keyring access. The existing dependency seam and store options already support this.

🛠️ Author fix: Contain every credential-store resolution reached by the new startup test in one pass, and verify seeded user paths remain untouched. Do not redesign the store or repair unrelated main tests.

🚫 Out of scope: Shared storage architecture and repository-wide test cleanup.

Needs maintainer decision

A child can interpret --api-key -Q... as a value, but the current collector intentionally treats a following dash-prefixed item as another flag; its negative test explicitly preserves --api-key --verbose. A real-child probe confirms the separated spelling can expose an opaque value, while equals/packed spellings redact it. There is no established child-independent grammar resolving that ambiguity. I am not requiring parser expansion or removal of the existing negative control in this PR; support for that separated form needs an explicit product choice.

Reviewed contract coverage

Lifecycle / ordering Changed entry points and siblings checked Operator claims
Configure -> prospectively validate -> existing locked atomic write -> reload -> adopt/invalidate Add, enable, disable, remove, manager actions and wizard; active/disabled aliases and exact/runtime identities No changed markdown/help/generated operator files; quoted transcript hints now address exact keys
Critical/optional capture -> retain -> completion -> consume -> config/token change -> close Connect/list/validation, snapshot/getter/channel, Init/handler/cache, both displays, replacement/disable/logout and runtime cancellation Reconnect and pending optional labels remain the documented scope exclusions
Error -> bound -> normalize/redact -> partial-tail repair -> display Configured/stored/inherited source matrix; HTTP/SSE body producer; real stdio capture In-memory transcript exposure verified; no claim that these command rows are directly persisted to session storage
Shared field/source Producer / load Capture / consumer Tests Docs / renderer
Exact key / runtime Name JSON, name validation, active uniqueness Exact actions vs canonical observations/tool counts/OAuth Padded keys, aliases, collisions, receiving CLI and hint tests Source comments consistent; latest hint fix verified
Configured credential fields and URL components Existing normalization and shared arg/URL parsing Reason and Target classifiers Short/escaped/split/padded/bounded fixtures plus query counterexamples Both displays traced; no changed operator-doc claim
Stored access/refresh/client-secret/endpoint and inherited sensitive env Store/login/refresh; documented env injection and launch merge/scrub Pre-connect/current context and both reasons Real refresh/child probes; credential-change controls inspected F1 covers omitted inputs; public identifiers/metadata preserved
Partial-body signal HTTP/SSE size/read boundary and error wrapping Existing tail suppression Complete, size-cut and interrupted-body probes F3 closes incomplete producer signaling

Malformed-input review covered empty/trailing/next flags, packed and equals boundaries, base64 padding, positional URLs, escaped query/fragment data, invalid URL fallback, independently invalid query pairs, wrong config shapes/types, missing transport requirements, invalid names/hosts/schemes, nil stores/errors, oversized values and UTF-8 cuts. The findings require local safety corrections, not new admission policy.

Validation

  • go build ./... and go vet ./internal/tui ./internal/mcp ./internal/redaction ./internal/cli passed.
  • Affected-package tests passed for MCP, redaction and CLI. The TUI package's only failure was TestRelativeAgeFormatsLastActivity (2025-05-31 versus 2025-06-01); the identical failure reproduces at base.
  • Focused MCP/credential tests with -race passed across TUI, MCP and CLI.
  • Review-owned overlay probes exercised real HTTP/SSE/stdio paths, both displays, immutable-base comparisons and simulated user-store migration. The reviewed checkout is unchanged.

- Redact what Login stores for refresh: the registered client secret,
  and the stored token endpoint, taken apart by the same URL rules as
  configured endpoints. Both now feed the credential fingerprint on the
  capture and display sides, so a rotated secret or a changed endpoint
  withholds an older reason.
- Redact credential-named variables a stdio child inherits from this
  process, with the readability floor, since a name match in the
  inherited environment is as often a switch as a secret.
- Classify each query value by its decoded key and decode the pairs one
  at a time, so an escaped short credential keeps its classification
  and a value beside an unparsable pair keeps its decoded spelling.
- Treat a failed body read as a cut, so a server that drops the
  connection part way through a credential no longer leaves an
  unmarked prefix.
- Withhold the reason when a terminal escape would eat part of a
  credential, which left every other byte of it on screen.
- Give the startup wiring test its own token store, so it no longer
  migrates or creates the user's token files.

Reported by @jatmn.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn thanks, all four are fixed in 56c7d55.

Stored and inherited credentials. SecretValues now includes the stored client secret. A new SecretMaterial returns the stored token endpoint separately, so the view takes it apart with the same URL rules as the configured endpoints instead of hiding the whole URL. Registration and the display both fingerprint CredentialMaterial (secrets plus endpoints) now, so a re-registered client secret or a changed endpoint reads as stale on both sides. For a stdio server the collector also reads os.Environ(), the same source mergeProcessEnv hands the child, and adds the credential-named variables. Those keep the readability floor, unlike configured env: in the whole inherited environment a credential-sounding name is as often a switch as a secret, and treating one as a known value would blank every 1 out of a stdio reason whenever something like ZERO_OAUTH_ALLOW_PRESETS=1 is set. PATH and the like stay out.

  • TestRefreshFailureEchoingStoredMaterialIsRedactedInBothViews drives a real 401 and a real refresh against a token endpoint that echoes both the client secret and the endpoint's workspace value.
  • TestRotatedStoredMaterialWithholdsTheReason covers both rotations, with an unchanged control.
  • TestInheritedCredentialEchoedByAStdioChildIsRedacted uses a real child with a credential-named, an ordinary and a short credential-named inherited variable.

Query provenance. Each query pair is now decoded on its own, and both spellings are classified by the decoded key. api%5Fkey=%73crt keeps %73crt as known, and a semicolon or a bad escape in another pair no longer drops the decoded spelling of the rest. The ParseQuery pass is gone. TestQueryCredentialsKeepTheirProvenanceInBothSpellings runs every combination you listed through the server URL, the three stdio argument shapes and the four OAuth endpoints, with short-metadata controls. TestQueryCredentialEchoedByAServerIsRedactedInBothViews does the two demonstrated cases through a real registration.

Interrupted bodies. httpStatusError marks the detail as cut when the read fails, and still slices by the length actually read. The producer test has interrupted HTTP and SSE cases, and TestCredentialPrefixCutByAnInterruptedBodyIsNotDisplayed checks both views.

Test isolation. The startup test gets a temporary file store with an empty environment through newMCPTokenStore. It also seeds stand-in user token paths through ZERO_MCP_OAUTH_TOKENS_PATH and ZERO_OAUTH_TOKENS_PATH and checks they are left alone. Without the seam, the run migrates the seeded legacy file.

One more I found while doing this. A bare ESC eats the byte after it, and a CSI eats its final byte. So a server that put one in front of a byte of an echoed credential got every other byte shown, and the old "bare escape byte" split case only passed because the whole value was gone. The reason is now also read with those bytes kept, and if a configured value only appears in that reading, the details are withheld. A sequence that eats nothing of the credential still rejoins it for normal redaction. TestAnEscapeThatEatsPartOfACredentialWithholdsTheReason and its real-HTTP counterpart cover it, and the split table now also asserts on [REDACTED].

Each fix was reverted on its own and its tests fail for the stated reason (13 mutations). Full suite, build, smoke, vet for windows/linux/darwin, govulncheck for linux and windows, and the pinned lint over tui, mcp and cli are clean.

On --api-key -Q...: I'd keep treating a dash-prefixed next item as a flag. Without the child's grammar, -Qx is as likely to be the next option as a value, and the = and packed spellings already cover the value case. I've written it into the description as a known limit; @kevincodex1 can say if he wants it the other way.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked this PR on head 56c7d55013cd86db5fdb5d89e7c03b745e85c4f1 (full diff, primary review, controller-run blind passes, GitHub/CI evidence). The startup-failure visibility work matches #825, and the redaction/identity fixes from prior review rounds are present on this head.

Merge readiness

  • Branch freshness: fix/825-mcp-panel-shows-failures matches live origin/main at 2e10a4b60b5ef8ebbf18bde8aea6dbfc65c7b43b (0 commits behind). Merge-base for the PR diff is 4247a07aba0f89c3c2597314a33edaede56be70c.
  • CI: All 12 executed checks on the latest GitHub run are green (Unit Tests & Coverage, Race Detector, smoke matrix, Zero Review, etc.). CodeRabbit skipped.
  • Local validation: Review relied on CI plus static inspection of the changed packages (go was not available in the review environment).

Needs maintainer decision

Separated --api-key -<opaque> argv spelling: A stdio child can treat a dash-prefixed next token as another flag; the collector deliberately does not redact that ambiguous shape (see PR “Known limit” and existing negative control around --api-key --verbose). Equals/packed spellings are covered. Whether to expand parsing for the separated form needs an explicit product decision; I am not requiring it in this PR.

Findings

🟢 Thanks for the contribution. I do not see any actionable issues from my review on head 56c7d550.

Prior CHANGES_REQUESTED items on older heads (stored OAuth material in the redaction input, per-pair query classification, HTTP body read incompleteness, startup test token-store isolation, transcript ConfigKey action hints) are addressed in the current tree. Out-of-scope items called out in the PR and earlier reviews remain out of scope here (in-session reconnect after enable, panel-grade redaction on stderr / bare zero mcp check output, separated dash-prefixed credential argv without a child grammar).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP panel shows configuration state, not connection reality

6 participants